#!/usr/bin/python3

# Inkbunny Sendmail 0.1.0
# Copyright 2025 JustLurking

# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.

# About
# This program will read an email containing single plain text message then
# connect to Inkbunny using the session cookie in the PHPSESSID environment
# variable and attempt to post the message to the user in the From field
# and in the thread identified by the In-Reply-To field.
#
# Not all email messages can be sent via ib-sendmail.  Rather than trying
# to convert feature rich emails and guess at the user's intent the program
# simply refuses to post an email where there is any ambiguity.  The following
# conditions must be met for an email to be posted by ib-sendmail:
#
# 1. Before any connection is made to the internet the message must have:
# a. one text/plain section
# b. zero or more multipart/* sections
# c. one recipient (either as an argument or in a To: header if -t is given)
# d. zero Cc: or Bcc: headers
# e. one sender (either in a From: header or given as an argument to -f)
# f. Both must have only a local part with no @ in the addresses.
# g. If there is an In-Reply-To it must be numeric and singular.
# h. There must be a subject header containing non-whitespace characters
#
# 2. Once a connection has been made to Inkbunny:
# a. The sender must be the logged in user.
# b. The recipient must be the other user in the thread if an In-Reply-To
#    header is set.

# Changelog
# 2025-01-18 JustLurking: Initial Release.

import argparse
import bs4
import email
import email.policy
import logging
import os
import re
import requests
import sys

# Variables used throughout the program.

# Used for identifying the program.
program_file  = "ib-sendmail"
program_name  = "Inkbunny Sendmail"
version       = "0.1.0"
bug_reports   = "https://Inkbunny.net/JustLurking/"
homepage      = "https://inkbunny.net/submissionsviewall.php?mode=pool&pool_id=98759"

# Used for logging.
log           = logging.getLogger(__name__)

# Used to download pages.
base_url      = "https://inkbunny.net/privatemessages.php"
cookie        = None
cookies       = requests.cookies.RequestsCookieJar()

# Used to validate input.
re_valid_email = re.compile("^[0-9a-zA-Z]+$")
re_valid_message_id = re.compile("^<?([0-9]+)>?$")

# Used to detect errors.
re_error = re.compile('error$')
re_footer = re.compile("\\bfooter\\b")

def download(url, **kwargs):
    """
    Utility wrapper for boiler plate around downloading and parsing pages.
    """
    global cookies
    resp = requests.get(url, cookies=cookies, params=kwargs)
    for (i, step) in enumerate(resp.history):
        log.debug("[%d] Downloaded: %s", i, step.url)
    log.debug("[Final] Downloaded: %s", resp.url)
    resp.raise_for_status()
    return bs4.BeautifulSoup(resp.content, features="lxml")

def get_logged_in_user(tag):
    """
    Get the logged-in user's name using the supplied HTML.
    """
    nav = tag.find(class_="userdetailsnavigation")
    if nav is None:
        log.critical("Unable to find user details on page.")
        exit(1)
    widget = nav.find(class_="widget_userNameSmall")
    if widget is None:
        log.critical("Unable to find user details on page.")
        exit(1)
    user = widget.get_text().strip()
    log.info("Logged in as %s.", user)
    return user

def get_other_user(tag):
    """
    Gets the other user's name using the supplied HTML.
    """
    reply = tag.find(id="reply")
    if reply is None:
        log.critical("Unable to find other user details on page.")
        exit(1)
    widget = reply.find(class_="widget_userNameSmall")
    if widget is None:
        log.critical("Unable to find other user details on page.")
        exit(1)
    user = widget.get_text().strip()
    log.info("Recipient is %s.", user)
    return user

def value(page, name):
    """
    Gets the value of an input field in the supplied HTML.
    """
    tag = page.find("input", attrs={"name": name})
    return tag["value"] if tag is not None else None

def report_issues(*issues):
    """
    Report any issues to the user and exit with a failure status if any are
    encountered.
    """
    abort = False

    for (found_issue, *msg) in issues:
        if found_issue:
            log.error(*msg)
            abort = True

    if abort:
        exit(1)

def report_site_issue(tag):
    """
    Report any issues to the user and exit with a failure status if any are
    encountered.
    """
    if tag.find('title').text != "Error | Inkbunny, the Furry Art Community":
        return

    msg = None
    for content in tag.find_all(class_='content'):
        if content.find_parent(id='usernavigation') is not None:
            continue
        if content.find_parent(class_=re_footer) is not None:
            continue
        msg = [ line.strip() for line in content.text.splitlines() ]

    if msg is None:
        print("An unknown error occurred.")
    else:
        print('\n'.join(line for line in msg if line != ""))

    exit(1)

# Main Program starts here.

# Configure logging.
log.addHandler(logging.StreamHandler())
log.setLevel(logging.INFO)

# Handle arguments.
arg_parser = argparse.ArgumentParser(
    prog = program_file,
    description = "".join((
        "Post a private message to inkbunny using an email as the source."
    )),
    epilog = "".join((
        "Report bugs to: "+bug_reports+"\n",
        program_name + " home page: <"+homepage+">\n"
    )),
    formatter_class = argparse.RawDescriptionHelpFormatter
)
arg_parser.add_argument(
    "-t",
    action = "store_true",
    help = "Read message for recipients. To:, Cc:, and Bcc: lines will be scanned for recipient addresses. The Bcc: line will be deleted before transmission."
)
arg_parser.add_argument(
    "-f",
    nargs = 1,
    help = "name Sets the name of the ''from'' person (i.e., the envelope sender of the mail)."
)
arg_parser.add_argument(
    "-s",
    "--session",
    nargs = 1,
    help = "the session id to send with requests"
)
arg_parser.add_argument(
    "-q",
    "--quiet",
    action = "count",
    default = 2,
    help = "decrease verbosity"
)
arg_parser.add_argument(
    "-v",
    "--verbose",
    action = "count",
    default = 0,
    help = "increase verbosity"
)
arg_parser.add_argument(
    "--save-final-response",
    const = "ib-sendmail-debug.html",
    nargs = '?'
)
arg_parser.add_argument(
    "-V",
    "--version",
    action = "store_true"
)
arg_parser.add_argument(
    "addresses",
    nargs = '*'
)

args = arg_parser.parse_args()

if args.version:
    print(
        " ".join((program_name, version)),
        "Copyright (C) 2025 JustLurking<https://inkbunny.net/JustLurking>",
        "License GPLv3+: GNU GPL version 3 or later "+
            "<https://gnu.org/licenses/gpl.html>",
        "",
        "This is free software: you are free to change and redistribute it.",
        "There is NO WARRANTY, to the extent permitted by law.",
        sep="\n"
    )
    exit(0)

log.setLevel(max(1, min(5, args.quiet-args.verbose))*10)

if args.session is not None:
    cookie = args.session

# Set the Session Cookie from the environment.
if cookie is None:
    cookie = os.getenv("PHPSESSID")

if cookie is None:
    log.error("Please use the -s argument or set the PHPSESSID environment variable to set the session id.")
    exit(1)

cookies.set("PHPSESSID", cookie, domain="inkbunny.net", path="/")

# Parse the email.
parser = email.parser.BytesParser(policy = email.policy.SMTP)
message = parser.parse(sys.stdin.buffer)

# 1. Check mail.
# 1.a. one text/plain section and 1.b. zero or more multipart/* sections.

found_plain_text_part = False
found_single_plain_text_part = True
found_no_other_part = True
for part in message.walk():
    if part.is_multipart():
        continue
    if part.get_content_type() == "text/plain":
        if found_plain_text_part:
            found_single_plain_text_part = False
        found_plain_text_part = True
    else:
        found_no_other_part = False

# 1.c one To: header if -t given.

to_addresses = args.addresses
if args.t and "To" in message:
    to_addresses.append(*message.get_all("To", []))

found_one_to_address = len(to_addresses) == 1

# 1.d. zero Cc: or Bcc: headers

found_no_cc_or_bcc_addresses = message.get_all("Cc") is None and message.get_all("Bcc") is None

# 1.e. a from header if -f is not given

from_addresses = message.get_all("From", [])
if args.f is not None:
    from_addresses.append(args.f)

found_one_from_address = len(from_addresses) == 1

# 1.f. Both must have only a local part.

found_valid_to_address = found_one_to_address and re_valid_email.match(to_addresses[0]) is not None
found_valid_from_address = found_one_from_address and re_valid_email.match(from_addresses[0]) is not None

# 1.g. If there is an In-Reply-To it must be numeric and singular.

in_reply_to = message.get_all("In-Reply-To", ())
found_in_reply_to = in_reply_to != ()
found_single_in_reply_to = len(in_reply_to) == 1
in_reply_to_match = re_valid_message_id.match(in_reply_to[0])
found_valid_in_reply_to = in_reply_to_match is not None
if in_reply_to_match is not None:
    in_reply_to = (in_reply_to_match.group(1),)

# h. There must be a subject header containing non-whitespace characters

found_valid_subject = message["Subject"].strip() != ""

# Report and abort if any errors have been detected.

report_issues(
    (not found_plain_text_part,
        "Email has no text/plain part."),
    (not found_single_plain_text_part,
        "Email should have a single text/plain part."),
    (not found_no_cc_or_bcc_addresses,
        "Email should not have CC or BCC recipients."),
    (not found_no_other_part,
        "Email should not have non-text/plain parts."),
    (not found_one_from_address,
        "Email should have one sender."),
    (not found_one_to_address,
        "Email should have one recipient."),
    (found_in_reply_to and not found_single_in_reply_to,
        "Email should be in reply to no more than one message."),
    (not found_valid_in_reply_to,
        "Email has invalid In-Reply-To header."),
    (not found_valid_from_address,
        "Email has invalid sender."),
    (not found_valid_to_address,
        "Email has invalid recipient."),
    (not found_valid_subject,
        "Email has invalid recipient."),
    (not found_plain_text_part,
        "Email has no text/plain part.")
)

# 2. Fetch the message submission page.

resp = requests.get(
    "https://inkbunny.net/privatemessageview.php",
    cookies = cookies,
    params = {'private_message_id': in_reply_to[0]} if found_in_reply_to else {}
)

if args.save_final_response is not None:
    with open(args.save_final_response, "wb") as f:
        f.write(resp.content)

resp.raise_for_status()

page = bs4.BeautifulSoup(resp.content, features="lxml")
report_site_issue(page)
user = get_logged_in_user(page)

# 2.a. The From must match the logged in user.

found_matching_sender = user.lower() == from_addresses[0].lower()

# 2.b. The To must match the other user if an In-Reply-To is given.

found_matching_recipient = True
if found_in_reply_to:
    other_user = get_other_user(page)
    found_matching_recipient = other_user.lower() == to_addresses[0].lower()

# Report and abort if any errors have been detected.

report_issues(
    (not found_matching_sender,
        "Email has invalid sender."),
    (not found_matching_recipient,
        "Email has invalid recipient.")
)

# Extract fields from the message submission page.

fields = {};

if found_in_reply_to:
    # This is a reply
    fields["to_user_id"]         = value(page, "to_user_id")
    fields["private_message_id"] = value(page, "private_message_id")
else:
    # This is a new message
    fields["to_username"]        = to_addresses[0]

fields["token"]        = value(page, "token")
fields["from_user_id"] = value(page, "from_user_id")

fields["subject"]      = message["Subject"]
fields["comment"]      = message.get_content()

report_issues(*(
    (True, "Unable to extract the value of the %s field from page.", k)
    for (k, v) in fields.items() if v is None
))

# Send message.

resp = requests.post(
    "https://inkbunny.net/privatemessageview_process.php",
    cookies = cookies,
    data = fields
)

if args.save_final_response is not None:
    with open(args.save_final_response, "wb") as f:
        f.write(resp.content)

resp.raise_for_status()
page = bs4.BeautifulSoup(resp.content, features="lxml")
report_site_issue(page)

# Detect and report non-site errors.

exit_status = 0
for error in page.find_all(id=re_error):
    print(error.text)
    exit_status = 1

if exit_status == 0:
    log.info("Message sent.");

exit(exit_status)
